返回博客列表Back to Blog

秒杀优化与 Redis 消息队列

2026-07-10 · 项目复盘 · 13 min read

1. 问题:为什么秒杀接口扛不住?

在一开始实现秒杀功能时,整个流程是同步串行的:

用户请求 → 查询优惠券 → 判断秒杀时间 → 判断库存 → 一人一单校验
                                                    │
                                          ┌─────────┴─────────┐
                                          │  扣减库存 (MySQL)    │
                                          │  创建订单 (MySQL)    │
                                          └───────────────────┘

所有操作都在主线程中串行执行,高并发下问题非常明显:

瓶颈 影响
全部走 MySQL 查库存、扣库存、创建订单、查一人一单,每个请求多次 DB 操作
响应时间长 用户必须等所有 DB 操作完成才能拿到结果
Tomcat 线程耗尽 每个请求占一个线程,线程在等 DB 时处于阻塞状态
吞吐量低 串行同步模型,单机能处理的 QPS 非常有限

一句话总结:秒杀的核心矛盾是"高并发请求"和"慢速 DB 写入"之间的矛盾。

显然,我们需要把这个长链路拆开。首先想到的是:能不能把最频繁的"判断"操作从 MySQL 搬到 Redis?


2. 第一步:秒杀资格判断前置到 Redis

思路很直接 —— 把"库存"和"一人一单记录"在秒杀前预热到 Redis,然后用 Lua 脚本一次完成所有判断:

-- seckill.lua
-- KEYS[1]: 库存 key  (seckill:stock:10)
-- KEYS[2]: 订单 set   (seckill:order:10)
-- ARGV[1]: 用户 ID
-- ARGV[2]: 订单 ID (预先生成)

-- 1. 判断库存
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil or stock <= 0 then
    return 1  -- 库存不足
end

-- 2. 判断一人一单
local isMember = redis.call('SISMEMBER', KEYS[2], ARGV[1])
if isMember == 1 then
    return 2  -- 已购买过
end

-- 3. 扣库存 + 记录用户
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])

return 0  -- 抢购成功

Java 调用:

public Result seckillVoucher(Long voucherId, Long userId) {
    long orderId = redisIdWorker.nextId("order");

    Long result = stringRedisTemplate.execute(
        SECKILL_SCRIPT,
        Arrays.asList(
            "seckill:stock:" + voucherId,
            "seckill:order:" + voucherId
        ),
        userId.toString(),
        String.valueOf(orderId)
    );

    if (result == 1) return Result.fail("库存不足!");
    if (result == 2) return Result.fail("每人限购一单!");

    // TODO: 这里还是要创建订单,怎么办?
    return Result.ok(orderId);
}

这一步的成果:资格校验从"多次 MySQL 查询"变成"一次 Redis Lua 调用",毫秒级完成。

但新问题来了:Lua 脚本成功后,订单还是要写入 MySQL。如果直接在接口里同步写库,之前省下来的时间又还回去了。我们需要一种方式,让"创建订单"这件事异步地、可靠地完成 —— 这就是消息队列登场的时刻。


3. 认识消息队列

消息队列的基本模型很简单:

生产者 ──→ [消息队列] ──→ 消费者
              (暂存)

在我们的场景中:

  • 生产者:秒杀接口,Lua 脚本成功后发一条"请创建订单"的消息
  • 消费者:后台线程,从队列取消息、写 MySQL
  • 消息队列:中间的缓冲区,负责削峰、解耦
用户请求
  │
  ▼
Redis Lua 判断 ──→ 抢到 → 发消息到队列 → 立刻返回"抢购成功"
  │                   │
  │                   ▼
  │             [消息队列缓冲]
  │                   │
  │                   ▼
  │             后台消费者慢慢写 MySQL
  │
  └── 没抢到 → 直接返回"库存不足"

三个核心作用:

作用 说明
异步 用户不用等 MySQL 写完,先拿到"抢购成功"的结果
解耦 秒杀判断和订单创建是两个独立模块,互不阻塞
削峰 瞬间涌入的请求先进入队列,消费者按自己的速度匀速处理

4. Redis 实现消息队列的三种方式

4.1 List 模拟队列

最简单的方式,用 Redis 的 List 数据结构:

生产者 LPUSH ──→ [ msg3, msg2, msg1 ] ──→ BRPOP 消费者
// 生产者
stringRedisTemplate.opsForList().leftPush("stream.orders", msg);

// 消费者:阻塞等待
String msg = stringRedisTemplate.opsForList()
    .rightPop("stream.orders", 5, TimeUnit.SECONDS);

缺点很明显:不支持消息确认(消费者崩溃消息就丢了)、不持久化、不支持消费者组。

4.2 Pub/Sub 发布订阅

PUBLISH channel:order msg → 所有订阅者同时收到

致命缺陷:消息不存储。如果没有消费者在线,消息直接丢弃。秒杀场景下丟一条消息就是一个真金白银的订单 —— 绝对不行

4.3 Stream(Redis 5.0+,生产推荐)

Stream 是 Redis 5.0 引入的真正意义上的消息队列,核心特性:

特性 说明
消息持久化 写入磁盘,重启不丢
消费者组 同组内负载均衡,一条消息只被一个消费者处理
消息确认 (XACK) 处理完确认,崩溃了消息留在 pending 等待重新处理
消息回溯 按 ID 或时间范围重新读取历史消息

消费者组的消息确认机制是 Stream 的核心优势:

消费者 XREADGROUP 取消息 → 消息进入 pending 状态
  ├─ 处理成功 → XACK 确认 → 消息从 pending 移除
  └─ 消费者崩溃 → 消息留在 pending → 其他消费者接手处理
                                              ↑
                                    保证消息不丢失!

生产者代码:

public void sendOrderMessage(VoucherOrder order) {
    Map<String, String> message = new HashMap<>();
    message.put("data", JSON.toJSONString(order));
    stringRedisTemplate.opsForStream().add("stream.orders", message);
}

消费者代码:

while (true) {
    // 1. 读取分配给自己的消息(阻塞等待)
    List<MapRecord<String, Object, Object>> records =
        stringRedisTemplate.opsForStream().read(
            Consumer.from(GROUP_NAME, CONSUMER_NAME),
            StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
            StreamOffset.create(STREAM_KEY, ReadOffset.lastConsumed())
        );

    if (records == null || records.isEmpty()) {
        handlePendingMessages();  // 检查有无之前崩溃遗留的 pending 消息
        continue;
    }

    for (MapRecord<String, Object, Object> record : records) {
        try {
            handleOrder(record);    // 2. 创建订单 (MySQL)
            acknowledge(record);    // 3. XACK 确认
        } catch (Exception e) {
            // 不 XACK → 消息留在 pending → 下次重试
        }
    }
}

5. 三种方案对比

特性 List Pub/Sub Stream
消息持久化 ⚠️ 依赖 RDB/AOF
消息确认 (ACK)
消费者组
消息回溯
实现复杂度 ⭐ 低 ⭐ 低 ⭐⭐⭐ 中
本场景推荐

6. 优化后的完整流程

用户请求
  │
  ▼
① Redis Lua 原子操作(毫秒级)
  ├─ 判断库存
  ├─ 判断一人一单
  └─ 扣库存 + 记录用户
  │
  ├─ 失败 → 返回"库存不足/已购买"
  │
  └─ 成功 → ② XADD 发送消息到 Stream
                  │
                  ▼
           ③ 立即返回"抢购成功"给用户
              
          [Redis Stream 持久化消息]
                  │
                  ▼
           ④ 后台消费者 XREADGROUP
                  │
                  ▼
           ⑤ 创建订单 (MySQL)
                  │
                  ▼
           ⑥ XACK 确认

完整 Service 代码:

public Result seckillVoucher(Long voucherId, Long userId) {
    long orderId = redisIdWorker.nextId("order");

    // ① Lua 原子判断
    Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
        Arrays.asList("seckill:stock:" + voucherId, "seckill:order:" + voucherId),
        userId.toString(), String.valueOf(orderId));

    if (result != 0) {
        return result == 1 ? Result.fail("库存不足") : Result.fail("已购买过");
    }

    // ② 发送到 Stream(异步下单)
    VoucherOrder order = new VoucherOrder();
    order.setId(orderId);
    order.setUserId(userId);
    order.setVoucherId(voucherId);

    Map<String, String> msg = new HashMap<>();
    msg.put("data", JSON.toJSONString(order));
    stringRedisTemplate.opsForStream().add("stream.orders", msg);

    // ③ 立即返回(不等待 MySQL)
    return Result.ok(orderId);
}

7. 总结

秒杀优化的核心就是三步:

步骤 做什么 关键手段
1 判断加速 Redis Lua 原子脚本替代 MySQL 查询
2 异步写入 消息队列分离"判断"和"写入"
3 削峰保护 Stream 缓冲 + 消费者匀速处理,保护 DB

技术选型建议:中小项目不想引入额外中间件时,Redis Stream 完全够用。消息量大、需要严格事务保证时再考虑 RocketMQ 或 Kafka。